import { NextRequest, NextResponse } from 'next/server'
import { createServerSupabaseClient } from '@/lib/supabase'
// GET /api/books/[id]/github-integration - Check if GitHub integration exists
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createServerSupabaseClient()
const bookId = params.id
// Get current user session (this works for client-side requests)
const authHeader = request.headers.get('authorization')
let user
try {
if (authHeader) {
// Handle if there's an auth header
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''))
if (!authError) user = authUser
} else {
// Try to get user from session
const { data: { user: sessionUser }, error: sessionError } = await supabase.auth.getUser()
if (!sessionError) user = sessionUser
}
} catch (e) {
// Ignore auth errors for now
}
if (!user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Verify user owns this book
const { data: book, error: bookError } = await supabase
.from('books')
.select('id, user_id')
.eq('id', bookId)
.eq('user_id', user.id)
.single()
if (bookError || !book) {
return NextResponse.json(
{ error: 'Book not found or access denied' },
{ status: 404 }
)
}
// Check for GitHub integration in user metadata
const { data: profile } = await supabase
.from('profiles')
.select('github_integrations')
.eq('id', user.id)
.single()
const githubIntegrations = profile?.github_integrations || {}
const integration = githubIntegrations[bookId]
if (integration) {
return NextResponse.json({
hasIntegration: true,
repository_name: integration.repository_name,
repository_full_name: integration.repository_full_name,
repository_url: integration.repository_url,
github_username: integration.github_username,
is_private: integration.is_private,
connected_at: integration.connected_at
})
} else {
return NextResponse.json({
hasIntegration: false
})
}
} catch (error) {
console.error('Error checking GitHub integration:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// POST /api/books/[id]/github-integration - Set up GitHub integration via OAuth
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createServerSupabaseClient()
const bookId = params.id
const { integration } = await request.json()
if (!integration) {
return NextResponse.json(
{ error: 'Integration data is required' },
{ status: 400 }
)
}
// Get user ID from integration data
const userId = integration.user_id
if (!userId) {
return NextResponse.json(
{ error: 'User ID required' },
{ status: 400 }
)
}
// Verify user owns this book
const { data: book, error: bookError } = await supabase
.from('books')
.select('id, user_id, title')
.eq('id', bookId)
.eq('user_id', userId)
.single()
if (bookError || !book) {
return NextResponse.json(
{ error: 'Book not found or access denied' },
{ status: 404 }
)
}
// Verify GitHub repo access
try {
const repoResponse = await fetch(`https://api.github.com/repos/${integration.repository_full_name}`, {
headers: {
'Authorization': `Bearer ${integration.access_token}`,
'Accept': 'application/vnd.github.v3+json'
}
})
if (!repoResponse.ok) {
return NextResponse.json(
{ error: 'Cannot access GitHub repository. Please check permissions.' },
{ status: 400 }
)
}
} catch {
return NextResponse.json(
{ error: 'Failed to verify GitHub repository access' },
{ status: 400 }
)
}
// Get current profile
const { data: profile } = await supabase
.from('profiles')
.select('github_integrations')
.eq('id', userId)
.single()
const githubIntegrations = profile?.github_integrations || {}
// Add this book's integration
githubIntegrations[bookId] = {
provider: integration.provider,
access_token: integration.access_token,
repository_url: integration.repository_url,
repository_name: integration.repository_name,
repository_full_name: integration.repository_full_name,
github_username: integration.github_username,
repository_id: integration.repository_id,
is_private: integration.is_private,
connected_at: new Date().toISOString()
}
// Update profile with GitHub integration
const { error: updateError } = await supabase
.from('profiles')
.update({
github_integrations: githubIntegrations,
updated_at: new Date().toISOString()
})
.eq('id', userId)
if (updateError) {
console.error('Error updating profile:', updateError)
return NextResponse.json(
{ error: 'Failed to save GitHub integration' },
{ status: 500 }
)
}
// Make initial commit with all book files
try {
await makeInitialCommit(supabase, bookId, userId, integration)
} catch (commitError) {
console.error('Failed to make initial commit:', commitError)
// Don't fail the integration setup if initial commit fails
}
return NextResponse.json({
message: 'GitHub integration set up successfully',
repository_name: integration.repository_name,
repository_full_name: integration.repository_full_name,
repository_url: integration.repository_url,
github_username: integration.github_username,
is_private: integration.is_private
})
} catch (error) {
console.error('Error setting up GitHub integration:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// DELETE /api/books/[id]/github-integration - Remove GitHub integration
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const supabase = createServerSupabaseClient()
const bookId = params.id
// Get current user
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
}
// Verify user owns this book
const { data: book, error: bookError } = await supabase
.from('books')
.select('id, user_id')
.eq('id', bookId)
.eq('user_id', user.id)
.single()
if (bookError || !book) {
return NextResponse.json(
{ error: 'Book not found or access denied' },
{ status: 404 }
)
}
// Get current profile
const { data: profile } = await supabase
.from('profiles')
.select('github_integrations')
.eq('id', user.id)
.single()
const githubIntegrations = profile?.github_integrations || {}
// Remove this book's integration
delete githubIntegrations[bookId]
// Update profile
const { error: updateError } = await supabase
.from('profiles')
.update({
github_integrations: githubIntegrations,
updated_at: new Date().toISOString()
})
.eq('id', user.id)
if (updateError) {
console.error('Error updating profile:', updateError)
return NextResponse.json(
{ error: 'Failed to remove GitHub integration' },
{ status: 500 }
)
}
return NextResponse.json({
message: 'GitHub integration removed successfully'
})
} catch (error) {
console.error('Error removing GitHub integration:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// Helper function to make initial commit with all book files
async function makeInitialCommit(supabase: any, bookId: string, userId: string, integration: any) {
// Get all book files
const { data: files } = await supabase
.from('file_system_items')
.select('*')
.eq('book_id', bookId)
if (!files || files.length === 0) {
throw new Error('No files found to commit')
}
// Build file structure
const buildFilePath = (fileId: string, allFiles: any[]): string => {
const file = allFiles.find(f => f.id === fileId)
if (!file) return ''
// For files, ensure extension is included
let fileName = file.name
if (file.type === 'file' && file.file_extension && !fileName.includes('.')) {
fileName = `${fileName}.${file.file_extension}`
}
if (!file.parent_id) return fileName
const parentPath = buildFilePath(file.parent_id, allFiles)
return parentPath ? `${parentPath}/${fileName}` : fileName
}
// Create file contents for commit
const fileContents: { [path: string]: string } = {}
files.forEach((file: any) => {
if (file.type === 'file') {
const filePath = buildFilePath(file.id, files)
fileContents[filePath] = file.content || ''
}
})
if (Object.keys(fileContents).length === 0) {
throw new Error('No file content to commit')
}
// GitHub API details
const owner = integration.github_username
const repo = integration.repository_name
const accessToken = integration.access_token
console.log(`Making initial commit for ${owner}/${repo}`)
// Add a small delay to allow GitHub to fully initialize the repository
await new Promise(resolve => setTimeout(resolve, 1000))
// Try to get current branch reference (might not exist for empty repo)
const branchResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json'
}
})
let parentSha = null
let isEmptyRepo = false
if (branchResponse.ok) {
const branchData = await branchResponse.json()
parentSha = branchData.object.sha
console.log(`Found existing branch with SHA: ${parentSha}`)
} else if (branchResponse.status === 404 || branchResponse.status === 409) {
// Empty repository - no main branch exists yet (404) or repository is empty (409)
isEmptyRepo = true
console.log(`Empty repository detected (${branchResponse.status}) - will create initial commit`)
} else {
// Log the actual error for debugging
const errorText = await branchResponse.text()
console.error(`GitHub API error (${branchResponse.status}):`, errorText)
throw new Error(`Failed to get branch reference: ${branchResponse.status} - ${errorText}`)
}
// For empty repositories, create tree directly without creating blobs first
if (isEmptyRepo) {
console.log('Creating tree directly for empty repository')
// For truly empty repositories, we need to initialize with a dummy commit first
// then we can use the proper Git Tree API
console.log('Initializing empty repository with dummy commit')
try {
// Create a simple initial commit to initialize the repository
const initResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/README.md`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: 'Initialize repository',
content: Buffer.from('# Repository initialized by BookWiz\n\nThis repository will contain your book files.').toString('base64')
})
})
if (!initResponse.ok) {
const errorText = await initResponse.text()
console.error(`Failed to initialize repository (${initResponse.status}):`, errorText)
throw new Error(`Failed to initialize repository: ${initResponse.status}`)
}
const initResult = await initResponse.json()
const initCommitSha = initResult.commit.sha
console.log(`Repository initialized with commit: ${initCommitSha}`)
// Now we can use the regular Git Tree API with this as the base
parentSha = initCommitSha
isEmptyRepo = false // Treat it as a normal repo now
// Continue to the normal blob creation process below
} catch (initError) {
console.error('Failed to initialize repository, falling back to Contents API:', initError)
// Ultimate fallback - but let's make it create a single commit by combining files
console.log('Using combined Contents API approach for empty repository')
// Create all files at once using multiple concurrent requests but with the same commit message
// This is still not ideal but better than sequential commits
const fileEntries = Object.entries(fileContents)
if (fileEntries.length === 1) {
// If only one file, create it directly
const [path, content] = fileEntries[0]
const fileResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: 'Initial commit: Add book files from BookWiz',
content: Buffer.from(content).toString('base64')
})
})
if (!fileResponse.ok) {
const errorText = await fileResponse.text()
throw new Error(`Failed to create file ${path}: ${fileResponse.status}`)
}
const result = await fileResponse.json()
console.log('Created single file using Contents API')
return result.commit
} else {
// Multiple files - this will unfortunately create multiple commits
// but it's the only option for truly empty repos when Git Tree API fails
console.log('WARNING: Will create multiple commits due to GitHub API limitations with empty repositories')
let lastCommit = null
for (const [path, content] of fileEntries) {
console.log(`Creating file: ${path}`)
const fileResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: 'Initial commit: Add book files from BookWiz',
content: Buffer.from(content).toString('base64')
})
})
if (!fileResponse.ok) {
const errorText = await fileResponse.text()
console.error(`Failed to create file ${path} (${fileResponse.status}):`, errorText)
throw new Error(`Failed to create file ${path}: ${fileResponse.status}`)
}
const result = await fileResponse.json()
lastCommit = result.commit
}
console.log(`Created ${fileEntries.length} files using Contents API fallback`)
return lastCommit
}
}
}
// Create blobs and tree entries
const treeEntries = await Promise.all(
Object.entries(fileContents).map(async ([path, content]) => {
const blobResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/blobs`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: Buffer.from(content).toString('base64'),
encoding: 'base64'
})
})
if (!blobResponse.ok) {
const errorText = await blobResponse.text()
console.error(`Failed to create blob for ${path} (${blobResponse.status}):`, errorText)
throw new Error(`Failed to create blob for ${path}: ${blobResponse.status}`)
}
const blob = await blobResponse.json()
return {
path,
mode: '100644',
type: 'blob',
sha: blob.sha
}
})
)
// Create new tree
const treePayload: any = {
tree: treeEntries
}
// Set base_tree if we have a parent commit
if (parentSha) {
treePayload.base_tree = parentSha
}
const newTreeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify(treePayload)
})
if (!newTreeResponse.ok) {
const errorText = await newTreeResponse.text()
console.error(`Failed to create tree (${newTreeResponse.status}):`, errorText)
throw new Error(`Failed to create tree: ${newTreeResponse.status}`)
}
const newTree = await newTreeResponse.json()
// Create commit
const commitPayload: any = {
message: 'Initial commit: Add book files from BookWiz',
tree: newTree.sha
}
// Set parents if we have a parent commit
if (parentSha) {
commitPayload.parents = [parentSha]
}
const commitResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify(commitPayload)
})
if (!commitResponse.ok) {
const errorText = await commitResponse.text()
console.error(`Failed to create commit (${commitResponse.status}):`, errorText)
throw new Error(`Failed to create commit: ${commitResponse.status}`)
}
const commit = await commitResponse.json()
// Update branch reference (always update since we now have a parent commit)
const updateRefResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
sha: commit.sha
})
})
if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text()
console.error(`Failed to update branch reference (${updateRefResponse.status}):`, errorText)
throw new Error(`Failed to update branch reference: ${updateRefResponse.status}`)
}
return commit
}